내결합성과 역할모델, 인터페이스

과제: 1차원배열의 모든 합을 재귀, 꼬리재귀, for문 세개 코드로 작성하기

내가 짠 코드

//재귀
const sum = (s, i) => (i > 0 ? s[i] + sum(s, i - 1) : s[0])

//꼬리물기
const taleSum = (s, i, acc) =>
  i > 0 ? taleSum(s, i - 1, acc + s[i]) : acc + s[0]

//for문
const arraySum = s => {
  let acc = 0
  for (let i = s.length - 1; i >= 0; i--) acc += s[i]
  return acc
}

예시 코드 1

const recursive = (list, index = 0, acc = 0) => {
  if (!Array.isArray(list)) return 0 // 내결합성
  if (typeof list[index] !== 'number') return acc
  return recursive(list, index, acc + list[index])
}

코드리뷰 내용

1. 오류와 실패

오류는 있는데 실패하지 않는 경우 : 내부에 있는 함수나 기능이 *내결합성을 갖는 경우

*내결합성 : 오류가 발생했을 때 버티는 경우, 대부분 런타임에러가 안생기는 대신 컨텍스트 에러가 생긴다. 프로그램이 죽지는 않지만 결과가 엉뚱하게 나온다. 안정성은 있지만 신뢰성이 제거된다. 안정성과 신뢰성은 trade-off 관계. 무조건 신뢰성을 높여라. 안정성은 시니어가 된다면 신경써라.
예를 들어 직원월급이 어떤사람은 100원이 나갔다면 이는 내결합성 때문. 아예 에러를 내뿜어야 한다.

프로그램에서 무서운 것은 컨텍스트 에러. 오류를 감추는 내결합성을 도입할 때는 신중해야 한다. 일반적인 경우 내결합성 도입은 무조건 좋지 않다. 내결합성을 갖추지 않고 오류를 표출하게끔 바꿔줘야 한다.

오류는 내결합성을 버티다가 더이상 버틸 수 없을 때.

아래처럼 수정

const recursive = (list, index = 0, acc = 0) => {
    if (!Array.isArray(list)) throw `invalid list ${list}`;
    if (typeof list[index]) !== 'number') throw `invalid element ${index}:${list[index]}`
    return recursive(list, index, acc + list[index]);
};
// 결정을 사용자가 해야 함.
// throw 대신 return 0 등으로 쓰는 것은 사용자의 권리를 뺏는 것.
try {
	recursive(17)
} catch(e) {
  console.log(e)
}

2. 코드의 역할 (역할모델)

const recursive = (list, index = 0, acc = 0) => {
    if (!Array.isArray(list)) throw `invalid list ${list}`; // 1
    if (typeof list[index]) !== 'number') throw `invalid element ${index}:${list[index]}` // 2
    return recursive(list, index, acc + list[index]); // 3
};

let result = 0;
try {
  recursive(17)
} catch(e) {
  console.log(e)
}

1번, 2번줄의 코드는 인자값이 제대로 들어왔는지 확인하는 코드.
3번줄의 코드는 계산하는 코드.

이렇게 한번에 겹쳐있으면 역할이 나눠진 코드가 아니다.
인자값이 제대로 들어왔는지 확인하는 함수를 짜서 코드의 역할을 분명히 해보자.

const listValidator = list => Array.isArray(list)
const elValidator = el => typeof el === 'number'

const recursive = (list, index = 0, acc = 0) => {
  if (!listValidator(list)) throw `invalid list ${list}`
  if (elValidator(list[index])) throw `invalid element ${index}:${list[index]}`
  return recursive(list, index, acc + list[index])
}

try {
  recursive(17)
} catch (e) {
  console.log(e)
}

왜 이렇게 ?

유지보수 때문. 코드분리 함수분기하는 것은 알고리즘이 복잡해서가 아니다.
역할은 수정원인. 수정원인이 다른 애들을 분리하자. listValidator와 elValidator의 인자, return값을 같게 만들었다. -> 자바스크립트의 인터페이스를 일치시켰다.
인터페이스를 일치시키면 컬렉션으로 묶을 수 있다. -> 일종의 일반화

한번 더 분리해보자

const validator = [
  (list, el) => Array.isArray(list),
  (list, el) => typeof el == 'number',
] // 컬렉션으로 묶은 모습

const recursive = (list, index = 0, acc = 0) => {
  if (!validator.every(vali => vali(list, list[index])))
    throw 'invalid arguments'
  return recursive(list, index, acc + list[index])
}

try {
  recursive(17)
} catch (e) {
  console.log(e)
}

validator를 깔끔하게 묶었다.
아직 덜 깔끔하다. recursive에서 validate를 하기 때문이다. 데이터와 데이터를 이용한 알고리즘이 이원화되면 관리가 불가능하다
-> 데이터를 소유한 쪽에서 데이터를 사용하는 알고리즘을 제공하도록 변경해보자.

분리하기

const validator = {
  data: [
    (list, el) => Array.isArray(list),
    (list, el) => typeof el == 'number',
  ],
  validate(list, index) {
    return this.data.every(vali => vali(list, list[index]))
  },
}

const recursive = (list, index = 0, acc = 0) => {
  if (!validator.validate(list, index)) throw 'invalid arguments'
  return recursive(list, index, acc + list[index])
}

try {
  recursive(17)
} catch (e) {
  console.log(e)
}

다른 코드

const _tailRecursiveSum = (array, i, acc) =>
  i > 0
    ? _tailRecursiveSum(array, i - 1, array[i] + acc)
    : (array[0] || 0) + acc
const tailRecursiveSum = array => _tailRecursiveSum(array, array.length - 1, 0)

(array[0] || 0) 이 부분도 내결합성에 해당된다.
array 가 비어있다면 합을 0으로 내는 것인데 이게 사용자가 원하는 것인가?

const err = msg => {
  throw msg
}

const _tailRecursiveSum = (array, i, acc) =>
  i > 0
    ? _tailRecursiveSum(array, i - 1, array[i] + acc)
    : (array[0] ?? err('invalid element index')) + acc

const tailRecursiveSum = array => _tailRecursiveSum(array, array.length - 1, 0)

for문으로 변환시켜보자

const iterateSum = arr => {
  let acc = 0
  for (let i = arr.length - 1; i > 0; i = i - 1) {
    acc = arr[i] + acc
  }
  acc = (arr[0] ?? err('invalid element index')) + acc
  return acc
}

재귀함수와 for문의 차이점

for문도 재귀함수처럼 함수를 계속 호출하는 것인데, 차이는 for문의 함수는 루프할 때 인자를 사용하지 않는다. 인자 대신 스코프에 있는 변수들을 사용한다. 재귀함수는 공유하고 있는 스코프는 없고 인자에만 의존하는 함수로 루프를 돌린다. 결국 for문은 클로저에만 의존하는 함수를 반복시키고, 재귀함수는 인자에만 의존하는 함수를 반복시킨다.

for문이 어려워지는 것은? 공유변수의 라이프사이클이 길어진다. 순수함수가 아니다. 사이드이펙트로만 돌아가는 함수.

for문이 왜 함수가 도는 것인가요?

이렇게 바꿔보면 명확히 알 수 있다.

const iterateSum = arr => {
  let acc = 0
  let i = arr.length - 1
  const f = () => (acc = arr[i] + acc)
  for (; i > 0; i = i - 1) f() // f()이 계속 호출
  acc = (arr[0] ?? err('invalid element index')) + acc
  return acc
}

문제는 위 코드들은 + acc가 중복된다. 고쳐보자

완성코드

const _tailRecursiveSum = (array, i, acc) =>
  i > -1 // -1로 수정했다.
    ? _tailRecursiveSum(array, i - 1, array[i] + acc)
    : acc
const tailRecursiveSum = array => _tailRecursiveSum(array, array.length - 1, 0)

const iterateSum = arr => {
  let acc = 0
  for (let i = arr.length - 1; i > -1; i = i - 1) {
    // 여기도 -1로 수정
    acc = arr[i] + acc
  }
  return acc
}

강의링크

코드스피츠 Programming101 - 2강


Written by@Jiyon Lee
뜨거운 코드를 가르며

GitHub